HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { AdminTitle, Bool, JsonPre, Mono, StatusChip } from '@/components/admin/ui';5import { KeyValue } from '@/components/ui/key-value';6import { Unavailable } from '@/components/ui/unavailable';7import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';8import { fmtBytes, fmtDateTime, fmtInt } from '@/lib/format';9import { routes } from '@/lib/site';1011export const metadata: Metadata = { title: 'Snapshot', robots: { index: false, follow: false } };12export const dynamic = 'force-dynamic';1314/** Render the API's `diff` as change rows when it is a map/array of changes, otherwise as JSON. */15function Diff({ diff }: { diff: unknown }) {16 if (diff === null || diff === undefined) return <p className="text-xs text-ink-3">No diff (first snapshot or unchanged).</p>;17 const rows: { key: string; before: unknown; after: unknown }[] = [];18 if (Array.isArray(diff)) {19 for (const [i, it] of diff.entries()) {20 if (it && typeof it === 'object') {21 const o = it as Record<string, unknown>;22 rows.push({ key: String(o.key ?? o.path ?? o.property ?? o.field ?? i), before: o.old ?? o.before ?? o.from ?? o.old_value, after: o.new ?? o.after ?? o.to ?? o.new_value });23 }24 }25 } else if (typeof diff === 'object') {26 const o = diff as Record<string, unknown>;27 const looksLikeMap = Object.values(o).every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('old' in (v as object) || 'new' in (v as object) || 'before' in (v as object) || 'after' in (v as object)));28 if (looksLikeMap) {29 for (const [k, v] of Object.entries(o)) {30 const c = v as Record<string, unknown>;31 rows.push({ key: k, before: c.old ?? c.before, after: c.new ?? c.after });32 }33 }34 }35 if (!rows.length) return <JsonPre value={diff} />;36 const show = (v: unknown) => (v === undefined || v === null ? <span className="text-ink-3">—</span> : <span className="break-all">{typeof v === 'string' ? v : JSON.stringify(v)}</span>);37 return (38 <ul className="divide-y divide-rule border-y border-rule text-xs">39 {rows.map((r) => (40 <li key={r.key} className="grid gap-x-3 gap-y-0.5 py-1.5 md:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1fr)]">41 <Mono className="text-ink">{r.key}</Mono>42 <span className="text-danger line-through decoration-danger/50">{show(r.before)}</span>43 <span className="text-positive">{show(r.after)}</span>44 </li>45 ))}46 </ul>47 );48}4950export default async function AdminSnapshotPage({ params }: { params: Promise<{ id: string }> }) {51 await requireAdmin();52 const { id } = await params;53 const res = await load(adminApi.snapshot(id));54 if (!res.ok && res.error.startsWith('404')) notFound();55 if (!res.ok) {56 return (57 <>58 <AdminTitle title="Snapshot" />59 <Unavailable what="Snapshot" reason={res.error} />60 </>61 );62 }63 const s = res.data;64 return (65 <>66 <p className="mb-2 text-xs">67 <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link>68 </p>69 <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>}>70 <Link href={`/admin/extractions/${encodeURIComponent(s.id)}`} className="inline-flex h-8 items-center border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">71 Extraction debugger →72 </Link>73 </AdminTitle>74 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]">75 <div className="min-w-0 space-y-8">76 <section>77 <p className="eyebrow mb-2">Structured extraction</p>78 <JsonPre value={s.structured} maxHeight="28rem" />79 </section>80 <section>81 <p className="eyebrow mb-2">Diff vs previous snapshot</p>82 <Diff diff={s.diff} />83 </section>84 <section>85 <p className="eyebrow mb-2">86 Cleaned text excerpt {s.text_chars !== undefined && <span className="tnum text-ink-3">{fmtInt(s.text_chars)} chars{s.text_truncated ? ' · truncated at 20 kB' : ''}</span>}87 </p>88 {s.text ? (89 <pre className="scrollbar-thin max-h-[32rem] overflow-auto whitespace-pre-wrap border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink-2">{s.text}</pre>90 ) : (91 <p className="text-xs text-ink-3">{s.text_error ? `Text unavailable: ${s.text_error}` : s.has_text === false ? 'No cleaned text for this snapshot (binary or JSON document).' : 'No text.'}</p>92 )}93 </section>94 </div>95 <aside className="min-w-0 space-y-6">96 <KeyValue97 dense98 rows={[99 { key: 'url', label: 'URL', value: <a href={s.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs">{s.url}</a> },100 { key: 'final_url', label: 'Final URL', value: s.final_url && s.final_url !== s.url ? <span className="break-all text-xs">{s.final_url}</span> : undefined },101 { key: 'connector', label: 'Connector', value: <Mono>{s.connector_name ?? '—'}</Mono> },102 { key: 'doc_type', label: 'Type', raw: s.doc_type },103 { key: 'entity', label: 'Entity', value: s.entity_slug ? <Link href={routes.entity({ entity_type: s.entity_id?.split('_')[0] ?? 'model', slug: s.entity_slug })} className="link text-xs">{s.entity_name ?? s.entity_slug}</Link> : undefined },104 { key: 'http_status', label: 'HTTP', raw: s.http_status },105 { key: 'content_type', label: 'Content type', raw: s.content_type },106 { key: 'byte_size', label: 'Size', raw: fmtBytes(s.byte_size) },107 { key: 'changed', label: 'Changed', value: <Bool v={s.changed} /> },108 { key: 'processing_status', label: 'Processing', value: <StatusChip value={s.processing_status} /> },109 { key: 'parser_version', label: 'Parser', raw: s.parser_version },110 { key: 'connector_version', label: 'Connector version', raw: s.connector_version },111 { key: 'transport', label: 'Transport', raw: s.transport },112 { key: 'content_hash', label: 'Content hash', value: s.content_hash ? <Mono className="break-all">{s.content_hash}</Mono> : undefined },113 { key: 'run_id', label: 'Run', value: s.run_id ? <Mono>{s.run_id}</Mono> : undefined },114 { key: 'has_raw', label: 'Raw archived', value: <Bool v={s.has_raw} /> },115 ]}116 />117 {s.headers && Object.keys(s.headers).length > 0 && (118 <div>119 <p className="eyebrow mb-2">Response headers</p>120 <JsonPre value={s.headers} maxHeight="14rem" />121 </div>122 )}123 </aside>124 </div>125 </>126 );127}128